iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
自我挑戰組

Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記系列 第 29 篇

Day 29 - 緩衝日④:登入與資料流程整合實戰

  • 分享至 

  • xImage
  •  

前面五篇我們依序介紹了參數化測試、Global Setup 與 storageState、多角色與 worker 專屬帳號,以及 Global Teardown。今天是最後一個緩衝日,我們用一個常見的情境把它們串起來,並在最後做個小補充:角色應該放在哪一層?

情境題目

每次上版前,想確認後台幾個主要列表頁都能正常打開,而且不同身分的人進去,看到的身分是對的;沒登入的人直接打網址,要被擋回登入頁。

具體要巡檢的範圍:

身分 要確認的事
一般使用者(demo) 商品、訂單、客戶、評論 4 個列表頁都能打開,右上角顯示 Jane Doe
管理員(admin) 同樣 4 個列表頁,右上角顯示 Admin User
未登入 直接進這 4 個網址,都會被導到登入頁

3 種身分 × 4 個頁面,總共 12 支測試,但我們希望在 setup 階段就把登入的操作處理完,測試本身只要專注在目標功能確認就好。

實作記錄:寫法 A

1. 先把要巡檢的頁面整理成資料表

參數化測試時提過,一筆資料應該包含「輸入」和「預期」。這裡的輸入是頁面網址,預期則是該頁面有確實載入完成的結果。

每個列表頁長得不一樣,能當作「載入完成」證據的元素也不同,所以把它也寫進資料表:

type Route = { name: string; path: string; ready: (page: Page) => Locator };

const routes: Route[] = [
    { name: '商品列表', path: '/#/products',  ready: (p) => p.getByTestId('product-result-count') },
    { name: '訂單列表', path: '/#/orders',    ready: (p) => p.getByRole('tab', { name: /^ordered/i }) },
    { name: '客戶列表', path: '/#/customers', ready: (p) => p.getByRole('columnheader', { name: /Last seen/i }) },
    { name: '評論列表', path: '/#/reviews',   ready: (p) => p.getByRole('columnheader', { name: /Product/i }) },
];
  • 商品列表:沿用前面在原始碼加上的 product-result-count。
  • 訂單列表:上方有 ordered (N)、delivered (N)、cancelled (N) 三個分頁,N 是筆數,所以用正規表示式只比對開頭。
  • 客戶、評論列表:是表格,用欄位標題當作載入完成的依據。

ready 寫成函式而不是直接放 Locator,這是因為 Locator 需要 page 才能建立,而資料表定義的時候還沒有 page。

2. 角色 × 頁面:用兩層迴圈產生測試

角色的部分沿用多角色那篇的做法:test.use({ storageState }) 寫在 describe 裡,切換這一組測試的身分。外層跑角色、內層跑頁面:

tests/day29-patrol.spec.ts

import { test, expect, type Page, type Locator } from '@playwright/test';
import fs from 'fs';
import path from 'path';

const tmpDir = path.join(__dirname, '../test-data/tmp');

type Route = { name: string; path: string; ready: (page: Page) => Locator };

const routes: Route[] = [
    { name: '商品列表', path: '/#/products',  ready: (p) => p.getByTestId('product-result-count') },
    { name: '訂單列表', path: '/#/orders',    ready: (p) => p.getByRole('tab', { name: /^ordered/i }) },
    { name: '客戶列表', path: '/#/customers', ready: (p) => p.getByRole('columnheader', { name: /Last seen/i }) },
    { name: '評論列表', path: '/#/reviews',   ready: (p) => p.getByRole('columnheader', { name: /Product/i }) },
];

const roles = [
    { role: '一般使用者', storageState: 'playwright/.auth/user.json',  profile: 'Jane Doe' },
    { role: '管理員',     storageState: 'playwright/.auth/admin.json', profile: 'Admin User' },
];

for (const r of roles) {
    test.describe(`[${r.role}]`, () => {
        test.use({ storageState: r.storageState });

        for (const route of routes) {
            test(`巡檢 ${route.name}`, async ({ page }, testInfo) => {
                await page.goto(route.path);

                // 身分正確
                await expect(page.getByRole('button', { name: 'Profile' })).toContainText(r.profile);
                // 頁面真的載入完成
                await expect(route.ready(page)).toBeVisible();

                // 留下一筆巡檢紀錄
                fs.mkdirSync(tmpDir, { recursive: true });
                fs.writeFileSync(
                    path.join(tmpDir, `patrol-w${testInfo.parallelIndex}-${Date.now()}.txt`),
                    `${r.role},${route.path},ok`
                );
            });
        }
    });
}

test.describe('[未登入]', () => {
    test.use({ storageState: { cookies: [], origins: [] } });

    for (const route of routes) {
        test(`直接進 ${route.name} 會被導到登入頁`, async ({ page }) => {
            await page.goto(route.path);
            await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible();
        });
    }
});

幾個跟前面幾篇對應的地方:

  1. 迴圈寫在 test() 外面:每筆資料各自是一支測試,其中一頁壞掉,不會讓其他頁面的巡檢跟著停下來。
  2. 角色放在 describe 標題,頁面放在 test 標題:報告裡會顯示成 [管理員] › 巡檢 訂單列表,哪個身分的哪一頁失敗一眼就看得出來。
  3. 完全沒有登入程式:user.json、admin.json 都是 setup 產生的,測試只負責「載入」。未登入則直接給空的 storageState。
  4. 巡檢紀錄的檔名:帶上 parallelIndex 和時間戳記,平行執行時不會撞名。這份紀錄本身沒什麼用途,主要是讓今天的流程有「測試過程產生的檔案」需要收尾。

3. 收尾

playwright.config.ts、auth.setup.ts、global.teardown.ts 都沿用前幾篇的版本:

  • setup project 登入 demo、admin,各存一份 storageState
  • chromium project 透過 dependencies: ['setup'] 確保 setup 先跑完
  • setup project 設了 teardown: 'teardown',所有測試跑完後,teardown 會清掉 test-data/tmp 和 playwright/.auth

4. 執行

先用 --list 看會產生哪些測試:

npx playwright test day29-patrol --project=chromium --list
Listing tests:
  [setup] › auth.setup.ts:41:6 › 登入一般使用者
  [setup] › auth.setup.ts:46:6 › 登入管理員
  [teardown] › global.teardown.ts:9:9 › 清除暫存檔
  [teardown] › global.teardown.ts:14:9 › 清除登入狀態檔
  [chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 商品列表
  [chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 訂單列表
  [chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 客戶列表
  [chromium] › day29-patrol.spec.ts:26:17 › [一般使用者] › 巡檢 評論列表
  [chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 商品列表
  [chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 訂單列表
  [chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 客戶列表
  [chromium] › day29-patrol.spec.ts:26:17 › [管理員] › 巡檢 評論列表
  [chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 商品列表 會被導到登入頁
  [chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 訂單列表 會被導到登入頁
  [chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 客戶列表 會被導到登入頁
  [chromium] › day29-patrol.spec.ts:49:13 › [未登入] › 直接進 評論列表 會被導到登入頁
Total: 16 tests in 3 files

預期會看到 2 支 setup、12 支巡檢,以及 2 支 teardown,且巡檢測試的標題會帶有 [角色] 前綴。

正式執行:

npx playwright test day29-patrol --project=chromium
Running 16 tests using 4 workers
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[teardown] › tests/global.teardown.ts:14:9 › 清除登入狀態檔
>>> teardown:清除 .auth
[teardown] › tests/global.teardown.ts:9:9 › 清除暫存檔
>>> teardown:清除 test-data/tmp
  16 passed (7.5s)

從輸出確認三件事:

  1. >>> setup:demo 登入、>>> setup:admin 登入 各只出現一次,即使 12 支測試分散在好幾個 worker 上跑
  2. 12 支巡檢測試全部通過
  3. 最後出現兩行 >>> teardown,且在跑完後 test-data/tmp 和 playwright/.auth 都不見了

補充:角色應該放在哪一層?

上面的寫法把角色放在 spec 檔裡(describe + test.use),但其實還有另一種常見的做法是把角色放在 config 裡,每個角色各開一個 project。

寫法 B:每個角色一個 project

在 playwright.config.ts 的 projects 加上兩個角色 project:

{
    name: 'role-user',
    testMatch: /day29-patrol-by-project/,
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
},
{
    name: 'role-admin',
    testMatch: /day29-patrol-by-project/,
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/admin.json' },
    dependencies: ['setup'],
},

原本的 chromium、firefox、webkit 也會掃到這支新的 spec,所以要在這三個 project 加上 testIgnore: /day29-patrol-by-project/,避免同一支測試又用預設身分多跑一次。

採用這個做法,本來的測試檔案就不用再寫角色迴圈了,只剩頁面迴圈。而測試要知道自己是哪個角色,可以從 testInfo.project.name 取得:

tests/day29-patrol-by-project.spec.ts

import { test, expect, type Page, type Locator } from '@playwright/test';

// routes 與 day29-patrol.spec.ts 相同,這裡省略

const profileByProject: Record<string, string> = {
    'role-user': 'Jane Doe',
    'role-admin': 'Admin User',
};

for (const route of routes) {
    test(`巡檢 ${route.name}`, async ({ page }, testInfo) => {
        await page.goto(route.path);
        await expect(page.getByRole('button', { name: 'Profile' }))
            .toContainText(profileByProject[testInfo.project.name]);
        await expect(route.ready(page)).toBeVisible();
    });
}

我們嘗試只跑一個角色看看會發生什麼事:

npx playwright test --project=role-admin
Running 8 tests using 4 workers
[setup] › tests/auth.setup.ts:46:6 › 登入管理員
>>> setup:admin 登入
[setup] › tests/auth.setup.ts:41:6 › 登入一般使用者
>>> setup:demo 登入
[teardown] › tests/global.teardown.ts:14:9 › 清除登入狀態檔
>>> teardown:清除 .auth
[teardown] › tests/global.teardown.ts:9:9 › 清除暫存檔
>>> teardown:清除 test-data/tmp
  8 passed (4.5s)

從上面的結果可以發現setup 登入了admin跟demo,因為我們剛剛雖然指定了要跑的project,但是setup維持了本來admin跟一般使用者的登入操作,所以setup + teardown 總共是4支測試,剩下的4支就是這次跑的project裡面要驗證的四個頁面了。

兩種寫法的比較

A:describe + test.use B:每個角色一個 project
角色寫在哪 spec 檔 config
報告標題 [chromium] › … › [管理員] › 巡檢 訂單列表 [role-admin] › … › 巡檢 訂單列表
只跑某個角色 -g "管理員" --project=role-admin
新增一個角色 roles 陣列多一筆 config 多一個 project,對照表多一筆
跟瀏覽器的關係 project 仍然是瀏覽器,互不影響 想同時測多個瀏覽器時,角色 × 瀏覽器的 project 數會相乘

那該選哪一種?

主要還是看你的測試目的:

  • 角色只是某幾支測試的條件,例如「管理員才看得到刪除按鈕」,用寫法 A。角色跟測試寫在一起,讀測試的人不用去看 config。
  • 整套測試都要用每個角色各跑一遍,而且想在 CI 上依角色拆開執行、各自看結果,用寫法 B。這時角色比較像「執行環境」,跟瀏覽器是同一個層次的東西,放在 config 裡比較自然。

以今天的巡檢來說,只有兩個角色、而且只有這一支 spec 需要切換身分,寫法 A 就足夠了。等到角色變多、需要每個角色都完整跑過一輪時,再考慮換成寫法 B。

小結

  1. 參數化:要巡檢的頁面整理成資料表,每筆資料同時包含「輸入」(網址)和「預期」(頁面就緒的依據),迴圈寫在 test() 外面。
  2. setup + storageState:登入在執行 setup 時發生,測試只負責載入,新增測試時完全不用管登入。
  3. 多角色:describe + test.use 切換身分,未登入也是一種角色。
  4. teardown:測試產生的暫存檔,由 teardown 在最後統一清掉,config 和 setup 都不用為了新測試修改。

到這裡,30 天的技術內容就全部介紹完了。明天是最後一天,我們會一起回顧這段時間學到了什麼,以及還有哪些能繼續深入的地方。


上一篇
Day 28 - Global Teardown:測試結束後的收尾
下一篇
Day 30 - 總結:30 天心得與回顧
系列文
Playwright 練功房:從零開始的 30 天 E2E 測試教學筆記 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言